Skip to content

perf(sequence): add From<Sequence<T>> for Vec<T> with Copy bound - #20

Merged
esteve merged 1 commit into
ros2-rust:mainfrom
azerupi:perf/fast-copy-basic-sequence-from-rmw
Jun 8, 2026
Merged

perf(sequence): add From<Sequence<T>> for Vec<T> with Copy bound#20
esteve merged 1 commit into
ros2-rust:mainfrom
azerupi:perf/fast-copy-basic-sequence-from-rmw

Conversation

@azerupi

@azerupi azerupi commented May 17, 2026

Copy link
Copy Markdown
Contributor

This is one of a set of 2 PRs that address issue number 2 described in ros2-rust/ros2_rust#628

This PR has to be merged before ros2-rust/rosidl_rust#26.

Problem

2. Element-by-element sequence conversion in from_rmw_message

The generated from_rmw_message code converts Sequence<T> to Vec<T> via .into_iter().collect(). The SequenceIterator::next() implementation (rosidl_runtime_rs/src/sequence.rs) does this per element:

let elem = ptr.read();
ptr.write(std::mem::zeroed::<T>());  // writes zero back for EVERY element

For a 64 KB Sequence<u8>, this is 65,536 individual read + zero-write + insert cycles instead of a single memcpy.

Solution

Adds impl<T: SequenceAlloc + Copy> From<Sequence<T>> for Vec<T> backed by seq.as_slice().to_vec(). For Copy element types (which covers all ROS 2 primitive types), this collapses to a single memcpy from the C-owned sequence buffer into a freshly-allocated Vec.

The companion PR in ros2-rust/rosidl_rust#26 updates the codegen template to call this impl on the receive side.

Microbenchmark

Using Criterion I ran a really quick benchmark between the before and after, just on the conversion from a Sequence<u8> of size 64K to a Vec<u8>. For my machine it gave the following numbers.

Path Time (median) Throughput
into_iter().collect() (old) 23.31 µs 2.62 GiB/s
as_slice().to_vec() 517.89 ns 117.85 GiB/s
.into() (new public From impl) 513.39 ns 118.89 GiB/s

Speedup of .into() over into_iter().collect() at 64 KiB: ~45×.

The .into() form matches as_slice().to_vec() within ~1%, confirming the From impl inlines to the same code path.

End-to-end pub/sub throughput

I also ran an end-to-end benchmark where I compared rclrs before and after the change as well as an rclcpp baseline.

The benchmark ran in a single process with one publisher thread sending std_msgs/UInt8MultiArray messages and the main thread runs blocking executor.spin(). Reliable + keep_last(1000) QoS, default rmw_fastrtps_cpp. 5 s runs, median of 3.

Note that all the benchmarks below have quite a bit of run-to-run variance. The numbers are to show the trend and not an absolute comparison.

Node subscription (default API)

Payload rclrs (main) rclrs (this branch) rclcpp baseline
0 B 93,048 msg/s 99,518 msg/s 129,383 msg/s
1 KiB 94,358 msg/s 79,448 msg/s 134,109 msg/s
16 KiB 107,232 msg/s 136,063 msg/s 216,831 msg/s
64 KiB 30,578 msg/s 135,560 msg/s 151,279 msg/s

At 64 KiB this branch gives a 4.4× rclrs throughput improvement and brings rclrs to ~90% of rclcpp single-process parity. At 16 KiB it gives 1.27× and at 0 B - 1 KiB the change is near-noise because the conversion path is short, and small-payload Node throughput is dominated by per-message executor dispatch overhead, see Worker numbers below.

Worker subscription

The same bench with node.create_worker(()).create_subscription(...), which dispatches sync callbacks directly on the wait-set thread (no BoxFuture allocation, no mpsc hop):

Payload rclrs (main) + Worker rclrs (this branch) + Worker
0 B 401,578 msg/s 394,115 msg/s
1 KiB 257,999 msg/s 388,214 msg/s
16 KiB 110,554 msg/s 325,895 msg/s
64 KiB 31,134 msg/s 224,576 msg/s

With Worker subscriptions, rclrs on this branch exceeds rclcpp single-process Node at 64 KiB (224 k vs 151 k msg/s, ~1.48×). The two fixes compose: the conversion fix removes the per-publish memcpy ceiling, and the Worker path removes the per-receive dispatch ceiling.

Again note that there is quite a bit of run-to-run variance.

While performing those benchmarks I also ran into a fun issue with colcon-ros-cargo: colcon/colcon-ros-cargo#41

For Copy element types (which covers all ROS 2 primitive types), this
impl collapses to a single memcpy from the C-owned sequence buffer
into a freshly-allocated Vec, via as_slice().to_vec() (stdlib
specializes <[T]>::to_vec on T: Copy).

The existing route via SequenceIterator::next() does a per-element
read + zeroed-write to keep the sequence safe to drop after iteration,
making into_iter().collect() O(n) Rust-level work where a single
memcpy would suffice. The cost is linear in the sequence length and
becomes the dominant cost for large primitive sequences in image and
point-cloud message types.

Microbench: Sequence<u8> 65536 -> Vec<u8>
  into_iter().collect()  23.3 us  (2.62 GiB/s)
  .into() (this impl)     513 ns  (118.89 GiB/s)
                         ~45x speedup

This is consumed by a companion change in rosidl_rust that updates
the codegen template to emit msg.field.into() for primitive sequence
fields in generated from_rmw_message impls.

Refs ros2-rust/ros2_rust#628 improvement (1).

@esteve esteve left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@azerupi thanks!

@esteve
esteve merged commit 1f32510 into ros2-rust:main Jun 8, 2026
romainreignier added a commit to romainreignier/ros-env that referenced this pull request Jun 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants